#!/usr/bin/env python

##  Module prattle.py
##
##  Copyright (c) 2012 Steven D'Aprano.
##
##  Permission is hereby granted, free of charge, to any person obtaining
##  a copy of this software and associated documentation files (the
##  "Software"), to deal in the Software without restriction, including
##  without limitation the rights to use, copy, modify, merge, publish,
##  distribute, sublicense, and/or sell copies of the Software, and to
##  permit persons to whom the Software is furnished to do so, subject to
##  the following conditions:
##
##  The above copyright notice and this permission notice shall be
##  included in all copies or substantial portions of the Software.
##
##  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
##  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
##  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
##  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
##  CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
##  TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
##  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

"""
===========
 prattle.py
===========

Text input functions for command-line applications
--------------------------------------------------

Text input functions suitable for getting input from the user in command-line
applications.



Readline support
----------------

If Python is compiled with readline support, and the ``readline`` module is
available, Prattle will automatically provide extra editing functionality,
such as editing lines in place. To disable these extra features at runtime,
set ``prattle.readline`` to None.

(Note: some readline editing functionality may remain after doing this.)

On Windows, the ``pyreadline`` module may be able to substitute for readline.
"""

# === Library metadata ===

__version__ = "0.3"
__date__ = "2012-11-16"
__author__ = "Steven D'Aprano"
__author_email__ = "steve+python@pearwood.info"

__all__ = ['edit_input', 'MenuError', 'MenuItem', 'Menu']


# === Imports ===

try:
    import readline
except ImportError:
    # May be Windows, so try using a substitute.
    try:
        import pyreadline as readline
    except ImportError:
        readline = None

import string
import sys


# === Python 2.x/3.x compatibility code ===

# This module is expected to work for all of Python 2.4 through to the most
# recent version of 3.x (currently 3.3).

try:
    unicode
except NameError:
    # Python 3.x
    _string = str
else:
    # Python 2.x
    _string = (str, unicode)

try:
    raw_input
except NameError:
    # Python 3.x
    pass
else:
    # Python 2.x, we shadow the input built-in with raw_input.
    input = raw_input

try:
    enumerate([], 1)
except TypeError:
    # Probably Python 2.4 or 2.5, where enumerate doesn't take the second
    # argument.  We fake it with a lightweight wrapper.
    _enumerate = enumerate
    def enumerate(it, start=0):
        for i, obj in _enumerate(it):
            yield (i+start, obj)


# === Custom exceptions ===

class MenuError(Exception):
    """Exception for when the user's response to a CLI menu does not
    match any menu item.

    If there are exactly two arguments passed, the second argument is taken
    to be the user's response, and is stored in the ``response`` attribute:

    >>> exception = MenuError("no matching menu item", "x")
    >>> exception.response
    'x'

    Otherwise ``response`` is None.
    """
    def __init__(self, *args):
        if sys.version < '2.5':
            # We can't use super here, because in Python 2.4 exceptions
            # are classic classes.
            Exception.__init__(self, *args)
        else:
            super(MenuError, self).__init__(*args)
        if len(args) == 2:
            self.response = args[1]
        else:
            self.response = None
    def __str__(self):
        # The str format here is based on that of IOError.
        numargs = len(self.args)
        if numargs == 2:
            return "[response: %r] %s" % (self.args[1], self.args[0])
        elif numargs == 1:
            return str(self.args[0])
        elif numargs == 0:
            return ''
        else:
            return str(self.args)


class MenuHook(Exception):
    """Exception used to run a hook method in a Menu."""
    def __init__(self, *args):
        if sys.version < '2.5':
            # We can't use super here, because in Python 2.4 exceptions
            # are classic classes.
            Exception.__init__(self, *args)
        else:
            super(MenuHook, self).__init__(*args)
        if args:
            self.message = args[0]
        else:
            self.message = ''


# === Utility functions ===

def edit_input(prompt='', initial='', capture_interrupt=None):
    """edit_input([prompt [, initial [, capture_interrupt]]]) -> string

    Read a string from standard input, giving an optional initial value.

    ``edit_input`` is similar to ``input`` in Python 3.x and ``raw_input``
    in Python 2.x. The major difference is that it takes an optional
    initial value which can be edited by the user. The specific behaviour
    of ``edit_input`` depends on the availability of the readline module.

    Example
    -------

    ``edit_input("Question? ", "answer")``

    If readline is available, this prompts the user:

        Question? answer

    with the word "answer" able to be edited, and the prompt "Question?"
    fixed. If the user immediately presses the Enter key, the result returned
    is "answer". Otherwise, the user can edit the answer before pressing the
    Enter key. The prompt can not be edited and is not returned.

    Without the readline module, editing of the initial answer is not
    possible. Instead, the prompt is displayed as this:

        Question? <answer>

    (note the angle brackets), and the edit buffer starts after the initial
    answer. If the user types something before pressing Enter, that text is
    returned, but if the user presses Enter without typing anything, the
    initial value "answer" is returned (excluding the angle brackets).

    Note that this last feature implies that users cannot give the empty
    string as their response unless the readline module is available. If you
    need to support entering the empty string even when readline is not
    present, see the section "Capturing Ctrl-C" below.

    If the user enters EOF (Ctrl-D on Unix, Ctrl-Z <Enter> on Windows) with
    no other text, EOFError is raised. This behaviour matches that of the
    builtin input function.

    Arguments
    ---------

    prompt              string displayed as a prompt
    initial             (optional) initial text for the user to edit
    capture_interrupt   (optional) three-state flag that controls the
                        behaviour of Ctrl-C

    The prompt string is printed without any trailing newlines. The initial
    value has all newlines removed.

    Capturing Ctrl-C
    ----------------

    The behaviour of Ctrl-C at the ``edit_input`` prompt depends on the
    optional ``capture_interrupt`` argument and the availability of readline.
    ``capture_interrupt`` is a three-state flag:

    * When it is None (the default), the behaviour is dependent on the
      availability of readline. If readline is present, then it behaves as
      if it were false; otherwise it behaves as if it were true.

    * When ``capture_interrupt`` is a false value (apart from None, above),
      Ctrl-C behaves as normal and raises KeyboardInterrupt.

    * When ``capture_interrupt`` is a true value, Ctrl-C is captured by
      ``edit_input`` and the empty string will be returned, regardless of
      what else the user may have typed.

    """
    prompt = prompt.rstrip('\n')
    initial = initial.strip('\n').replace('\n', ' ')
    if capture_interrupt is None:
        capture_interrupt = readline is None
    try:
        if readline if not None:
            readline.set_startup_hook(lambda: readline.insert_text(initial))
            try:
                response = input(prompt)
            finally:
                readline.set_startup_hook(None)
        else:
            # No readline, so the default response isn't editable.
            if initial:
                prompt += '<%s> ' % initial
            response = input(prompt)
            if response == '':
                response = initial
    except KeyboardInterrupt:
        if not capture_interrupt:
            raise
        response = ''
    return response


def istype(obj, T):
    """Return True if obj is a subclass of T or an instance of T.
    """
    if not isinstance(T, type):
        raise TypeError("expected a type object or class but got %r" % T)
    if isinstance(obj, type):
        return issubclass(obj, T)
    else:
        return isinstance(obj, T)


def do_callback(callback, menu, *args):
    if callback is None:
        pass
    elif isinstance(callback, type) and issubclass(callback, Exception):
        raise callback(*args)
    elif isinstance(callback, Exception):
        raise callback
    else:
        return callback(menu, *args)


def show_help(message):
    """Display a help message to the user."""
    print(message)
    # TODO use the pydoc pager?


# === Menu handling ===

# The hierarchy of concerns is:
#
#   - A MenuItem is concerned with a single command.
#   - A Menu collects one or more MenuItems and handles the user input.
#   - A *Dialog handles high-level logic regarding prompts and menus.


class MenuItem(object):
    """Object representing a single menu item in a CLI (text/console) menu.

    A MenuItem represents a single command; multiple MenuItems are
    collected into a Menu.

    ARGUMENTS
    ---------

    MenuItems take two required arguments and two optional argument:

        item:       The menu item title (a string).
        number:     The number of the menu item, numbered from left to right,
                    starting at 1 (an int).
        action:     Optional callback function or None.
        default:    Optional flag, defaulting to False. If default is true,
                    the menuitem is highlighted when printed and responds to
                    user input specially.

    >>> print (MenuItem('Ham', 1))
    [Ham]
    >>> print (MenuItem('Spam', 2, default=True))
    [[Spam]]

    The caller is responsible for ensuring that MenuItems are numbered
    correctly (no missing or duplicate numbers) and that there is only one
    default item per Menu, otherwise behaviour is undefined.

    If ``action`` is not None, it should be a function that will be called
    with a single argument, the MenuItem instance. The return result is
    ignored.

    Example usage:

    >>> def command(instance):  # Define an action function.
    ...     print ("Perform some command.")
    ...
    >>> item = MenuItem('Command', 1, command)
    >>> user_input = 'com'  # The user typed this to select the menu item.
    >>> if item.match(user_input):
    ...     selection = item.select()
    ...
    Perform some command.
    >>> selection
    'Command'

    MenuItems also support two output hooks to customize the result of the
    user selecting the item. See the ``select`` method for details.
    """
    template = "[%s]"
    case_sensitive = False

    def __init__(self, title, number, action=None, default=False):
        self.title = title
        self.number = number
        self.action = action
        self.default = default

    # Title and string displays.

    def __repr__(self):
        name = self.__class__.__name__
        args = ['%r, %d' % (self._raw_title, self.number)]
        if self.action:
            args.append('action=%r' % self.action)
        args.append('default=%s' % bool(self.default))
        return '%s(%s)' % (name, ', '.join(args))

    def __str__(self):
        image = self.template % self.image
        if self.default:
            image = self.template % image
        return image

    @property
    def image(self):
        """Display image of a MenuItem in a menu."""
        return self.title

    def _get_title(self):
        return self._title
    def _set_title(self, item):
        self._title = self.clean(item)
        self._raw_title = item
    title = property(_get_title, _set_title)
    del _get_title, _set_title

    @staticmethod
    def clean(item):
        """menuitem.clean(string) => string

        >>> MenuItem.clean(" label \\n")
        'label'

        Clean a string, preparing it for use as menu item.
        """
        if not isinstance(item, _string):
            raise TypeError('expected a string but got %r' % type(item))
        item = item.strip().replace('\n', ' ')
        if not item or item.isspace():
            raise ValueError('menu items must be non-blank strings')
        return item

    # Menu item matching and selection.

    def match(self, response):
        """menuitem.match(response) => True|False

        Return True if the user's response (a string or int) matches the
        menuitem, otherwise returns False.

        A response matches if:

            (1) response is an int equal to the menuitem's item number;
            (2) response is the empty string or all whitespace and menuitem
                is the default item; or
            (3) response is a string which matches a prefix of the menuitem.

        >>> item = MenuItem('ham', 3)
        >>> item.match('h')
        True

        """
        if isinstance(response, int):
            return response == self.number
        response = response.strip()
        if response == '':
            return bool(self.default)
        return self.match_prefix(self.title, response, self.case_sensitive)

    @staticmethod
    def match_prefix(target, prefix, case_sensitive=True):
        """Return True if prefix matches the start of target, otherwise False.
        The comparison is case sensitive by default.

        >>> MenuItem.match_prefix('python', 'py')
        True

        """
        if not case_sensitive:
            # FIXME This isn't strictly correct, as it will give the wrong
            # results with some non-ASCII characters such as Turkish dotted
            # and dotless I.
            try:
                a = target.casefold()
                b = prefix.casefold()
            except AttributeError:
                a = target.lower()
                b = prefix.lower()
            target, prefix = a, b
        return target.startswith(prefix)

    def select(self):
        """menuitem.select() => value

        Select the menuitem, perform any optional action associated with the
        item, and return the selected value.

        >>> def action(item):
        ...     print('Do something useful')
        ...
        >>> mi = MenuItem('Be useful', 1, action)
        >>> mi.select()
        Do something useful
        'Be useful'

        By default the selected value returned is the menu item's title.
        To change that, you can define one of two hooks:

            (1) To use a static value, chosen ahead of time, define an
                attribute ``value`` with the value you want:

                >>> item = MenuItem('Save', 1)
                >>> item.select()
                'Save'
                >>> item.value = 23
                >>> item.select()
                23

            (2) To use a computed value dynamically calculated as needed,
                define an attribute ``getvalue`` with a callable (function
                or type). It will be called with the menu item as argument:

                >>> del item.value
                >>> item.getvalue = lambda item: item.title.upper() + '!'
                >>> item.select()
                'SAVE!'

        """
        if self.action:
            self.action(self)
        if hasattr(self, 'getvalue'):
            return self.getvalue(self)
        elif hasattr(self, 'value'):
            return self.value
        else:
            return self._raw_title
        return item


class NumberedMenuItem(MenuItem):
    """Like MenuItem, except that items display their number and respond to
    being selected by the user by number as well as by title.

    >>> print (NumberedMenuItem('Spam', 2))
    [2:Spam]

    """
    def match(self, response):
        """menuitem.match(response) => True|False

        Like MenuItem.match, except that it also matches the user's response
        against the item number as well as the title.

        >>> item = NumberedMenuItem('ham', 3)
        >>> item.match('3')
        True

        In contrast, a MenuItem would attempt to match the string '3' against
        the title 'ham', not the number, and so return False.
        """
        if isinstance(response, _string) and response.strip().isdigit():
            if int(response) == self.number:
                return True
        return super(NumberedMenuItem, self).match(response)

    @property
    def image(self):
        return "%d:%s" % (self.number, self.title)


class Menu(object):
    """Object representing a menu of selectable items using a text interface.

    A Menu represents a list of MenuItems, and logic to handle the user
    choosing one of those items.

    ARGUMENTS
    ---------

    Menu objects take a single required argument:

        titles:     List of string titles, or a single space-delimited
                    string of titles.

    Optional arguments are:

        label:      String labelling the menu. Defaults to the empty string.
        default:    Item to be used as the default menuitem. Defaults to -1.
        actions:    None, or a dict of callbacks to be associated with the
                    menuitems. Defaults to None.
        callbacks:  None, or a dict of additional callbacks. Defaults to None.
        on_error:   Error handler callback. Defaults to MenuError.
        kind:       Callable that returns a menu item. Defaults to
                    NumberedMenuItem.

    Acceptable values for the `default` argument are:

        -1:         Use the right-most menuitem as the default.
        0           Select default using '!' metacharacter.
        int n       Use menuitem numbered n as the default.
        string s    Use menuitem with title s as the default.
        None        Do not use a default menuitem.

    Callbacks can be either None, an exception, or a function. See the
    ``select`` method for further detail on callbacks.

    OTHER ATTRIBUTES
    ----------------

    Menus have attributes ``help_text`` and ``error_text``. When appropriate,
    they are used for displaying help or error messages to the user before
    returning to the menu. Both must be strings, and they support the
    following optional $ substitutions:

        $COUNT      The number of menuitems in the menu.
        $RESPONSE   The string typed by the user.
        $HELP       The string that the user can type to get help.

    """

    error_message = 'don\'t just stand there, do something!'

    def __init__(self, titles, label='', default=-1, actions=None,
                 callbacks=None, on_error=MenuError, kind=NumberedMenuItem):
        if isinstance(titles, _string):
            titles = titles.split()
        if actions is None:
            actions = {}
        self.menuitems = self.build_menuitem_list(titles, actions, kind)
        self.default = default
        self.label = label
        self.callbacks = (callbacks or {}).copy()
        self.on_error = on_error

    def build_menuitem_list(self, titles, actions, kind):
        if not titles:
            raise ValueError('list of menu item titles must not be empty')
        menuitems = []
        for i, title in enumerate(titles, 1):
            menuitem = self.build_menuitem(title, i, actions, kind)
            menuitems.append(menuitem)
        return menuitems

    def build_menuitem(self, title, i, actions, kind):
        if isinstance(title, _string):
            return kind(title, i, actions.get(title), False)
        raise TypeError('expected string but got %r' % type(obj))

    def __len__(self):
        return len(self.menuitems)

    # String representation.

    def __str__(self):
        label = self.label
        if label is None:
            label = ''
        # Make sure there is whitespace between the label (if any) and the
        # rest of the menu display.
        if label and not label[-1].isspace():
            label += ' '
        items = ' '.join([str(item) for item in self.menuitems])
        return label + items

    # Handle setting and getting the default menuitem.

    def _getdefault(self):
        return self._default

    def _setdefault(self, default):
        n = self.normalize_default(default)
        assert n is None or 1 <= n <= len(self.menuitems)
        prev = getattr(self, '_default', None)
        if prev is not None and prev != n:
            self.menuitems[prev-1].default = False
        if n is not None:
            self.menuitems[n-1].default = True
        self._default = n

    default = property(_getdefault, _setdefault)
    del _getdefault, _setdefault

    def normalize_default(self, default):
        if default == -1:
            default = len(self.menuitems)
        if default == 0:
            # Select default using metacharacter '!' in the title.
            for i, item in enumerate(self.menuitems, 1):
                if item.title.startswith('!'):
                    default = i
                    item.title = item.title[1:]
                    break
            else:
                raise ValueError("no menuitem with leading '!' was found")
        elif isinstance(default, int):
            # Select default by number.
            if not 1 <= default <= len(self.menuitems):
                raise ValueError('default out of range 1...%d' % n)
        elif isinstance(default, _string):
            # Select default by title.
            for i, item in enumerate(self.menuitems, 1):
                if item.title == default:
                    default = i
                    break
            else:
                raise ValueError("no menuitem with title '%s'" % default)
        elif default is None:
            return
        else:
            raise ValueError('invalid choice as default menuitem')
        assert 1 <= default <= len(self.menuitems)
        return default

    # User input and menu handling.

    # FIXME should this be in the Menu class, or external?
    # Keep it private for now.
    def _get_input(self, prompt):
        """Display a prompt and the menu, and query the user for input."""
        if prompt not in (None, ''):
            print(prompt)
        return input(str(self) + " ").strip()

    def find_matching_item(self, response):
        """instance.find_matching_item(response) => menuitem

        Return the menu item matching the user's response, or
        None if no item matches.

        >>> items = Menu("A B C D")
        >>> items.find_matching_item('x') is None
        True
        >>> items.find_matching_item('c')  # doctest:+ELLIPSIS
        MenuItem('C', ...)

        """
        if response:
            # Search for the first item to match the user response.
            #   FIXME should this find the best match instead?
            #   e.g. 'cat' will match items CAT and CATTLE and currently
            #   returns whichever it sees first. Should it prefer CAT even
            #   if it comes after CATTLE?
            for item in self.menuitems:
                if item.match(response):
                    return item
        elif self.default is not None:
            item = self.menuitems[self.default-1]
            assert item.match(self.default)
            return item

    def do_callback(self, callback, response):
        if callback is None:
            return None
        elif isinstance(callback, type) and issubclass(callback, Exception):
            raise callback(self.error_message, response)
        elif isinstance(callback, Exception):
            raise callback
        else:
            return callback(self, response)

    def do_hook(self, exc, response):
        raise NotImplementedError

    def select(self, prompt=None):
        """Display a menu and wait for the user to select a menu item.

        Returns the user's response, or (by default) raises an exception if
        the response does not correspond to any item. The usual exception
        raised is MenuError.

        ``Menu.select`` is highly customizable via four mechanisms:

        1)  Any menuitem can have have an action callback installed to change
            the response, or raise an exception. See section "Menuitems"
            below for further details.

        2)  The menu can set additional callbacks which run on a given
            response. See section "Callbacks" below.

        3)  The menu can set an error handler, which is called if the user's
            response does not match either a menuitem nor a menu callback.

        4)  And of course Menu can be subclassed.


        Menuitems
        ---------

        If the user selects a menuitem, by default the title of the menuitem
        is returned. However, menuitems can set an action callback to
        customize this behaviour. Additionally, menuitems contain hooks to
        customize their return result, instead of the menuitem's title. See
        the documentation for MenuItem for details.


        Callbacks
        ---------

        Menu callbacks can customize the menu's behaviour when a result does
        not match a menuitem:

        * If the callback or error handler is None, the user's response is
          simply ignored and the menu displayed again.

        * If the callback or error handler is a MenuHook exception (an
          instance or class), the ``Menu.do_hook`` method is called and the
          menu displayed again. The method is called with the exception and
          the user's response as arguments. The return result is ignored.

          The default ``do_hook`` method raises NotImplementedError and must
          be overridden, either by subclassing or with a per-instance method.

        * If the callback or error handler is an exception other than
          MenuHook, that exception will be raised. By default, Menus have an
          ``on_error`` handler of MenuError.

          If the exception is an instance, then that instance will be raised
          as given. If it is a class, then it will be called with two
          arguments, an error message and the user's response. The error
          message is taken from the menu attribute ``error_message``.

        * Any other object should be callable. It will be called with two
          arguments, the menu and the user's response. If it raises a MenuHook
          exception, the ``do_hook`` method will be called as above, otherwise
          exceptions are propogated as normal. If the callable returns a
          result other than None, it is taken as a successful menu selection
          and returned.

        """
        sentinel = object()
        selection = None
        while selection is None:
            response = self._get_input(prompt)
            item = self.find_matching_item(response)
            if item is None:
                # Look for a callback and call it.
                cb = self.callbacks.get(response, sentinel)
                if cb is sentinel:
                    # No callback registered for the user's response.
                    # Try the error handler.
                    cb = self.on_error
                try:
                    selection = self.do_callback(cb, response)
                # TODO: Once we no longer support Python 2.4 and 2.5, we
                # can write this more naturally as "except MenuHook as err".
                except MenuHook:
                    e = sys.exc_info()[1]
                    assert isinstance(e, MenuHook)
                    selection = e
            else:
                selection = item.select()
            if istype(selection, MenuHook):
                self.do_hook(selection, response)
                selection = None
        return selection


class MenuPlus(Menu):
    """Like Menu, but automatically provides help and error handlers."""
    HELPKEY = '?'
    help_text = '''
    ::  Select a menu item by typing the first letter(s) of its name,
    ::  or its number 1...$COUNT, and then press Enter.
    '''
    error_text = '''
    ::  No menu item matches '$RESPONSE', please try again;
    ::  or enter '$HELP' for more help.
    '''

    def __init__(self, menuitems, label='', default=-1, actions=None,
                 callbacks=None, on_error=None, kind=NumberedMenuItem):
        if callbacks is None:
            callbacks = {}
        if self.HELPKEY not in callbacks:
            def handler(menu, response):
                raise MenuHook(self.help_text, response)
            callbacks[self.HELPKEY] = handler
        if on_error is None:
            def handler(menu, response):
                raise MenuHook(self.error_text, response)
            on_error = handler
        super(MenuPlus, self).__init__(
                menuitems, label, default, actions, callbacks, on_error, kind
                )

    def do_hook(self, exc, response):
        msg = string.Template(exc.message)
        msg = msg.safe_substitute(
                HELP=self.HELPKEY, RESPONSE=response, COUNT=len(self)
                )
        print(msg)


# === High-level user input classes ===

class AnswerDialog(object):

    DEFAULT_MENUITEMS = ('Cancel', 'Okay')

    def __init__(self, menuitems=None, default=-1,
                 help_text='', error_text=''
                ):
        if menuitems is None:
            menuitems = self.DEFAULT_MENUITEMS
        menu = MenuPlus(menuitems, label='    ', default=default)
        if help_text != '':
            menu.help_text = help_text
        if error_text != '':
            menu.error_text = error_text
        self.menu = menu

    def __call__(self, prompt, menuitems=None, default=-1,
                       help_text='', error_text=''):
        if menuitems is None:
            menu = self.menu
            menu.default = default
        else:
            menu = MenuPlus(menuitems, label='    ', default=default)
        if help_text != '':
            menu.help_text = help_text
        if error_text != '':
            menu.error_text = error_text
        # Get user input.
        selection = menu.select(prompt)
        return selection


class AskDialog(object):

    DEFAULT_MENUITEMS = ('Edit', 'Cancel', 'Okay')

    def __init__(self, menuitems=None, help_text='', error_text=''):
        if menuitems is None:
            menuitems = self.DEFAULT_MENUITEMS
        dialog = AnswerDialog(menuitems, None, help_text, error_text)
        if len(dialog.menu) != 3:
            raise ValueError('menu must have exactly three items')
        # Set the menuitems to return known values, regardless of what
        # they are called.
        for i, token in enumerate(('EDIT', 'CANCEL', 'OKAY')):
            # Aieeee! Violation of Law of Demeter. <wink>
            dialog.menu.menuitems[i].value = token
        self.dialog = dialog

    def __call__(self, prompt, initial=''):
        response = initial
        while True:
            print(prompt)
            response = edit_input(': ', response)
            item = self.dialog('')
            if item == 'EDIT':
                print('')
                continue
            elif item == 'CANCEL':
                return None
            elif item == 'OKAY':
                return response
            else:
                raise RuntimeError('internal error with menuitem %r' % item)



# === High-level text interface commands ===

# "ask" and "answer" functions are inspired by similar functions in
# Rebecca Bettencourt's OpenXION <http://www.openxion.org/>, in turn
# inspired by Apple's long gone and much missed Hypercard.
#
# http://www.kreativekorp.com/docs/openxion/1.1/manual/cm.html#ask
# http://www.kreativekorp.com/docs/openxion/1.1/manual/cm.html#answer


answer = AnswerDialog()
ask = AskDialog()

